ENG-1977 Add schema import data layer for Obsidian - #1264
Conversation
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
3af9adf to
f364d5a
Compare
23c69c1 to
d417d43
Compare
d417d43 to
bc0e07d
Compare
f364d5a to
09bb0e7
Compare
bc0e07d to
34254ad
Compare
5119839 to
cfff9bf
Compare
34254ad to
9073b20
Compare
cfff9bf to
3820532
Compare
9073b20 to
26d1ad2
Compare
26d1ad2 to
6b684dc
Compare
b0abbd4 to
2a39a08
Compare
PR size/scope checkThis PR is over our review-size guideline.
Please split this into smaller PRs unless there is a clear reason the changes need to land together. If keeping it as one PR, please add a brief justification covering:
|
… set, not schema intersection
…types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lySchemaImportSelection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The remote-space import and the schema-file import both had to answer "does this incoming type already exist locally?", and answered it differently: the Supabase path compared names and labels case-sensitively while specImport lowercased them. The same vault reached through the two paths would dedupe differently. Extracts schemaMatching.ts with the id-then-name/label fallback, the triple identity check, and buildSchemaRid — which pins the "schema" RID subtype so both paths emit byte-identical RIDs. Node instance and relation instance RIDs keep their own "note"/"relation" subtypes and are untouched. Matching is now case-insensitive on both paths. This is a behavior change to the Supabase import: importing a "Claim" type into a vault holding "claim" now reuses the local type instead of creating a near-duplicate. specImport sets importedFromRid from the file's vaultId, so schema imported from a file and content imported from that same vault via Supabase resolve to the same RID. Like the Supabase path, this records the immediate source vault rather than preserving an older origin. Also fixes a duplicate-triple hole the case-insensitive matching widens: the apply loop guarded against triples that existed at plan time but not against ones created earlier in the same run, so two schema node types collapsing onto one local type produced duplicate triples. The check now runs against live settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
31a387c to
83e0548
Compare
2a39a08 to
2f98215
Compare
A schema file holding both "Event" and "event" created two node types that matching then treats as one, because the existing-check only compared against the vault's types as they were before the import. Same hole for relation types by label. Fixed in the planner rather than at apply time: the known-set grows as types are planned, so the second type resolves to the first the same way it would resolve to a pre-existing local type. Keeping it in the planner means nodeTypeIdMapping stays correct — skipping the duplicate at apply time would leave discourse relations pointing at an id that was never created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re: size/scope checkCurrent: +532 −44 across 4 files — within the file-count preference, over the 400-line limit. Single problem: make an exported schema file importable into a vault, resolving incoming ids against what already exists there. Why the changes are coupled: That said, a clean split does exist and I don't want to overstate the coupling:
Splitting this way would actually improve review quality: the behavior change to the shipped Supabase import currently sits underneath 400+ lines of new file, which is exactly where it's easiest to miss. The cost is one more PR in an already 5-deep stack and a re-stack of #1265. Happy to do the split if a reviewer prefers it — say the word. |
Provisional exists so schema arriving from a Supabase space can be reviewed before it takes effect. A file import is different: the user chose the file and hand-picked the items, so there is nothing left to review. The importedFromRid is still recorded for provenance. The Supabase import path (importRelations.ts) is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| let discourseRelationsCreated = 0; | ||
| for (const relation of schemaFile.discourseRelations) { | ||
| if (!selectedRelationIds.has(relation.id)) { | ||
| continue; | ||
| } | ||
|
|
||
| const mappedSourceId = | ||
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | ||
| const mappedDestinationId = | ||
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | ||
| relation.destinationId; | ||
| const mappedRelationTypeId = | ||
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | ||
| relation.relationshipTypeId; | ||
|
|
||
| // Checked against live settings, not the plan: distinct schema node types can | ||
| // collapse onto one local type, so two file relations can map to one triple. | ||
| const alreadyPresent = findExistingTriple({ | ||
| discourseRelations: plugin.settings.discourseRelations, | ||
| sourceId: mappedSourceId, | ||
| destinationId: mappedDestinationId, | ||
| relationshipTypeId: mappedRelationTypeId, | ||
| }); | ||
| if (alreadyPresent) { | ||
| continue; | ||
| } | ||
|
|
||
| const newRelation: DiscourseRelation = { | ||
| ...relation, | ||
| id: uuidv7(), | ||
| sourceId: mappedSourceId, | ||
| destinationId: mappedDestinationId, | ||
| relationshipTypeId: mappedRelationTypeId, | ||
| importedFromRid: buildSchemaRid({ | ||
| spaceUri: sourceSpaceUri, | ||
| localId: relation.id, | ||
| }), | ||
| status: "accepted", | ||
| modified: Date.now(), | ||
| }; | ||
| plugin.settings.discourseRelations = [ | ||
| ...plugin.settings.discourseRelations, | ||
| newRelation, | ||
| ]; | ||
| discourseRelationsCreated += 1; | ||
| } |
There was a problem hiding this comment.
Critical data integrity bug: Discourse relations can reference node types or relation types that don't exist.
When creating discourse relations, the code maps IDs but doesn't verify that the referenced node types and relation type actually exist in the vault. If a user selects a discourse relation but NOT the node types it connects or the relation type it uses, the relation will be created with dangling references.
Scenario:
- Schema file has: NodeType A, NodeType B, RelationType X, DiscourseRelation (A→B via X)
- User selects only the DiscourseRelation, not the types
- Result: Relation created with non-existent type IDs
Fix: Before creating the relation, verify the referenced types exist:
const sourceExists = plugin.settings.nodeTypes.some(nt => nt.id === mappedSourceId);
const destExists = plugin.settings.nodeTypes.some(nt => nt.id === mappedDestinationId);
const relationTypeExists = plugin.settings.relationTypes.some(rt => rt.id === mappedRelationTypeId);
if (!sourceExists || !destExists || !relationTypeExists) {
onWarning(
`Discourse relation skipped: references missing types (source: ${mappedSourceId}, dest: ${mappedDestinationId}, type: ${mappedRelationTypeId})`
);
continue;
}| let discourseRelationsCreated = 0; | |
| for (const relation of schemaFile.discourseRelations) { | |
| if (!selectedRelationIds.has(relation.id)) { | |
| continue; | |
| } | |
| const mappedSourceId = | |
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | |
| const mappedDestinationId = | |
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | |
| relation.destinationId; | |
| const mappedRelationTypeId = | |
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | |
| relation.relationshipTypeId; | |
| // Checked against live settings, not the plan: distinct schema node types can | |
| // collapse onto one local type, so two file relations can map to one triple. | |
| const alreadyPresent = findExistingTriple({ | |
| discourseRelations: plugin.settings.discourseRelations, | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| }); | |
| if (alreadyPresent) { | |
| continue; | |
| } | |
| const newRelation: DiscourseRelation = { | |
| ...relation, | |
| id: uuidv7(), | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| importedFromRid: buildSchemaRid({ | |
| spaceUri: sourceSpaceUri, | |
| localId: relation.id, | |
| }), | |
| status: "accepted", | |
| modified: Date.now(), | |
| }; | |
| plugin.settings.discourseRelations = [ | |
| ...plugin.settings.discourseRelations, | |
| newRelation, | |
| ]; | |
| discourseRelationsCreated += 1; | |
| } | |
| let discourseRelationsCreated = 0; | |
| for (const relation of schemaFile.discourseRelations) { | |
| if (!selectedRelationIds.has(relation.id)) { | |
| continue; | |
| } | |
| const mappedSourceId = | |
| matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; | |
| const mappedDestinationId = | |
| matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? | |
| relation.destinationId; | |
| const mappedRelationTypeId = | |
| matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? | |
| relation.relationshipTypeId; | |
| // Checked against live settings, not the plan: distinct schema node types can | |
| // collapse onto one local type, so two file relations can map to one triple. | |
| const alreadyPresent = findExistingTriple({ | |
| discourseRelations: plugin.settings.discourseRelations, | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| }); | |
| if (alreadyPresent) { | |
| continue; | |
| } | |
| const sourceExists = plugin.settings.nodeTypes.some( | |
| (nt) => nt.id === mappedSourceId | |
| ); | |
| const destExists = plugin.settings.nodeTypes.some( | |
| (nt) => nt.id === mappedDestinationId | |
| ); | |
| const relationTypeExists = plugin.settings.relationTypes.some( | |
| (rt) => rt.id === mappedRelationTypeId | |
| ); | |
| if (!sourceExists || !destExists || !relationTypeExists) { | |
| onWarning( | |
| `Discourse relation skipped: references missing types (source: ${mappedSourceId}, dest: ${mappedDestinationId}, type: ${mappedRelationTypeId})` | |
| ); | |
| continue; | |
| } | |
| const newRelation: DiscourseRelation = { | |
| ...relation, | |
| id: uuidv7(), | |
| sourceId: mappedSourceId, | |
| destinationId: mappedDestinationId, | |
| relationshipTypeId: mappedRelationTypeId, | |
| importedFromRid: buildSchemaRid({ | |
| spaceUri: sourceSpaceUri, | |
| localId: relation.id, | |
| }), | |
| status: "accepted", | |
| modified: Date.now(), | |
| }; | |
| plugin.settings.discourseRelations = [ | |
| ...plugin.settings.discourseRelations, | |
| newRelation, | |
| ]; | |
| discourseRelationsCreated += 1; | |
| } | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
…cally Existing items were silently skipped, so a user who already had a Claim node type could never take the imported format, template or color onto it. applySchemaImportSelection now accepts an optional merge plan and applies only the fields it names. An absent or empty plan keeps every local value, so import stays non-destructive unless the caller opts a field in. The plan is keyed by schema-file id rather than local id: buildSchemaImportMatchPlan deliberately collapses schema types that collide by normalized name, so two schema ids can share one local id and a local-keyed map would drop one of them. name and label are excluded from the mergeable sets. Matching is id-first, so an id match carrying a different name reads as a rename, but renaming a type does not retag pages already tagged with it — the vault would silently split. SchemaImportMatchPlan moves to schemaMatching.ts so the apply path and the field-diff path can both depend on it without a circular import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The create path only keeps an imported template reference when the template will actually exist — imported in this run, or already in the vault. The merge path copied imported.template unguarded, so ticking template for a node type whose template is neither selected nor local left it pointing at a file that does not exist. Both paths now share resolveTemplateReference so they cannot drift apart, and merge warns when a ticked reference is dropped rather than silently ignoring an explicit choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt fields Two changes, both making merge non-destructive. Templates: overwriteTemplateFile clobbered the local file. Instead reuse createTemplateFileWithUniqueName — the same helper the Supabase import path uses — so the imported version lands as "Claim (from their-vault)" and the node type is repointed at that copy. The user keeps both and can switch back by editing the node type. resolveTemplateReference now keys off what actually landed rather than what was selected, so a failed creation leaves no dangling reference. Version skew: a field the file has no value for is not an instruction to clear the local one. An export from an older plugin simply lacks fields it never knew about, and offering those as changes turned version skew into silent deletion. The diff now skips absent imported values, so merge only ever adds or overwrites. Fields the local item has never set are still offered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
https://www.loom.com/share/7771ec3f58e749599a5bf406debdb101
Summary
Adds the import data layer — file parsing, match planning, and apply logic. The UI modal is in the next PR.
specImport.tsbuildSchemaImportMatchPlan— pure, no side effects. Maps every id in the schema file to a local id, id match first and name/label fallback second, and records which items already exist locally.applySchemaImportSelection— creates new relation types and relations asprovisional, skips existing matches, preserves a node type's template reference when that template is selected for import or already exists locally.parseDgSchemaFile(inspecValidation.ts) does full Zod validation againstdgSchemaFileSchema, including aversionliteral guard.schemaMatching.ts— matching primitives shared with the Supabase import path (see below).Planning is deliberately separated from applying. The Supabase import path interleaves them —
mapNodeTypeIdToLocalandmapRelationTypeToLocalboth find-or-create inline, so side effects happen during what reads like a lookup. Keeping the plan pure means the preview in #1265 can show exact counts without touching settings.Shared matching with the Supabase import path
Both import paths have to answer "does this incoming type already exist locally?" — and they answered it differently. The Supabase path compared names and labels case-sensitively;
specImportlowercased them. The same vault reached through the two paths would dedupe differently.schemaMatching.tsnow holds the shared primitives:findLocalNodeTypeMatchmapNodeTypeIdToLocal,buildSchemaImportMatchPlanfindLocalRelationTypeMatchmapRelationTypeToLocal,buildSchemaImportMatchPlanfindExistingTriplefindOrCreateTriple,buildSchemaImportMatchPlanbuildSchemaRidmapNodeTypeIdToLocal,mapRelationTypeToLocal,applySchemaImportSelectionbuildSchemaRidpins the"schema"RID subtype in one place so the two paths cannot drift. Node instance ("note") and relation instance ("relation") RIDs are a different concern and are untouched.Only the finder half of
findOrCreateTriplewas extracted, not the create half — it callsplugin.saveSettings()per triple, whileapplySchemaImportSelectionbatches a single save at the end. Sharing the whole function would have turned one write into N.Name and label matching is now case-insensitive on both paths. Importing a
Claimnode type into a vault that already holdsclaimnow reuses the local type instead of creating a near-duplicate. This is a change to an already-shipped code path and is the one thing in this PR worth reviewing on its own merits.importedFromRidprovenanceapplySchemaImportSelectionsetsimportedFromRidon created node types, relation types, and relations, derived from the file'svaultId(added in #1263) asorn:obsidian.schema:<appId>/<localId>.This is byte-identical to what the Supabase path produces for the same vault and local id, so schema imported from a file and content imported from that same vault over Supabase resolve to the same RID.
Like the Supabase path, this records the immediate source vault. If vault B exports a type it originally imported from vault C, the RID points at B, not C — consistent with existing behavior, but it means RIDs are one hop of provenance, not a full chain.
Bug fixed along the way
The apply loop guarded discourse relations against triples that existed at plan time, but not against triples created earlier in the same run. Two schema node types collapsing onto one local type therefore produced duplicate triples. The guard now runs against live settings.
This was latent before this PR, but case-insensitive matching widens the window:
Claimandclaimin one schema file now collapse onto a single local type where previously they did not.Stack
PR 4 of 5 for FEE-840. Stacks on #1263.
Test plan
Verified:
pnpm --filter @discourse-graphs/obsidian check-typespassespnpm --filter @discourse-graphs/obsidian lint— 0 errors (75 pre-existing warnings, none in touched files)ridToSpaceUriAndLocalId(buildSchemaRid(...))recovers the originalspaceUri/localIdpairfindLocalNodeTypeMatch/findLocalRelationTypeMatch— id match beats a differing name; name/label fallback folds case and trims whitespace; genuinely new items returnundefinedfindExistingTriple— matches on endpoints while ignoring the triple's own id; reversed endpoints and a different relation type both correctly miss